Parte 08 - Introducción a los Planes

Contexto

Aquí introducimos un objeto crucial para escalar industrialmente el Aprendizaje Federado: El Plan. Reduce dramáticamente el uso de ancho de banda, permite esquemas asíncronos y da más autonomía a dispositivos remotos. El concepto original del Plan puede ser encontrado en el artículo Towards Federated Learning at Scale: System Design, pero ha sido adaptado a nuestras necesitades en la librería Pysyft.

La intención de un Plan es almacenar una secuencia de operaciones torch, igual que una función, pero permitiendo enviar dicha secuencia de operaciones a trabajadores remotos y mantener una referencia a el. De esta manera, para computar remotamente la secuencia de $n$ operaciones en una entrada remota referenciada a través de punteros, en lugar de enviar $n$ mensajes ahora debes enviar un solo mensaje con las referencias al Plan y los punteros. También puedes proveer tensores con tu función (los que llamamos state tensors) para tener funcionalidades extendidas. Los Planes pueden ser vistos como una función que puedes enviar o como una clase que puede ser enviada y ejecutada remotamente. Por lo tanto, para usuarios de alto nivel, la noción de un Plan desaparece y es reemplazada por una característica mágica que permite permite enviar a trabajadores funciones arbitrarias que contienen funciones secuenciales torch.

Algo para notar es que la clase de funciones que puedes transformar en Planes está actualmente limitada a secuencias de operaciones torch enganchadas. Esto excluye en particular a estructuras lógicas como declaraciones de if, for y while, aunque estamos trabajando para tener soluciones alternativas pronto. Para ser completamente precisos, puedes usarlos pero el camino lógico que tomes (el primer if que resulte False y 5 bucles en for, por ejemplo) en la primer computación de tu Plan será en la que seguirás durante todas las siguientes computaciones, cosa que queremos evitar en la mayoría de los casos.

Autores:

Importaciones y especificaciones para el modelo

Primero hagamos las importaciones oficiales.


In [ ]:
import torch
import torch.nn as nn
import torch.nn.functional as F

Note que en las importaciones específicas a Pysyft: el trabajador local no debe ser un trabajador de cliente. Trabajadores que no son de cliente pueden almacenar objetos y necesitamos de eso para correr un Plan.


In [ ]:
import syft as sy  # importar la librería Pysyft
hook = sy.TorchHook(torch)  # enganchar PyTorch (por ejemplo para agregar funcionalidad extra)

# IMPORTANTE: El trabajador local no debe ser un trabajador de cliente
hook.local_worker.is_client_worker = False


server = hook.local_worker

Definimos trabajadores remotos o devices, para que sean consistentes con las nociones provistas en el artículo de referencia. Los proveemos de unos datos.


In [ ]:
x11 = torch.tensor([-1, 2.]).tag('input_data')
x12 = torch.tensor([1, -2.]).tag('input_data2')
x21 = torch.tensor([-1, 2.]).tag('input_data')
x22 = torch.tensor([1, -2.]).tag('input_data2')

device_1 = sy.VirtualWorker(hook, id="device_1", data=(x11, x12)) 
device_2 = sy.VirtualWorker(hook, id="device_2", data=(x21, x22))
devices = device_1, device_2

Ejemplo Básico

Definamos una función que queremos transformar en un Plan. Hacerlo es tán simple como agregarle un decorador encima de la definición de la función.


In [ ]:
@sy.func2plan()
def plan_double_abs(x):
    x = x + x
    x = torch.abs(x)
    return x

Revisemos, ¡ahora tenemos un plan!


In [ ]:
plan_double_abs

Para usar un Plan, necesitas dos cosas: construir el Plan (por ejemplo, registrar la secuencia de operaciones presentes en la función) y enviarlo al trabajador / dispositivo. Afortunadamente puedes hacer esto muy fácilmente.

Construir un Plan

Para construir un Plan solo necesitas llamarlo con algo de datos.

Primero consigamos la referencia para datos remotos: una requisición es enviada a la red y el puntero de referencia es regresado.


In [ ]:
pointer_to_data = device_1.search('input_data')[0]
pointer_to_data

Si le decimos al plan que debe ser ejecutado remotamente en el dispositivo location:device_1... obtendremos un error porque el Plan no ha sido construido todavía.


In [ ]:
plan_double_abs.is_built

In [ ]:
# Enviar un Plan no construido fallará
try:
    plan_double_abs.send(device_1)
except RuntimeError as error:
    print(error)

Para construir un Plan solo necesitas llamar a build en el Plan y pasar los argumentos necesesarios para ejecutar el Plan (a.k.a unos datos). Cuando el Plan es construido, todos los comandos son ejecutados secuencialmente por el trabajador local, son atrapados por el Plan y almacenados en el atributo actions.


In [ ]:
plan_double_abs.build(torch.tensor([1., -2.]))

In [ ]:
plan_double_abs.is_built

Ahora ya podemos enviar el plan


In [ ]:
# Ahora podemos ejecutar exitosamente esta celda
pointer_plan = plan_double_abs.send(device_1)
pointer_plan

Así como con los tensores, obtenemos un puntero al objeto enviado. Se le llama simplemente un PointerPlan

Algo importante a recordar es que cuando un Plan es construido, predefinimos las id(s) donde el resultado(s) debe ser almacenado antes de hacer la computación. Esto permitirá enviar comandos asíncronamente, para ya tener una referencia a un resultado virtual y continuar las computaciones locales sin esperar que el resultado remoto. Una aplicación importante es cuando quieras hacer un cómputo de un grupo de datos en device_1 y no quieras esperar a que la computación termine para lanzar otro cómputo de grupo en device_2.

Correr un Plan remotamente

Ahora podemos correr el Plan remotamente llamando el puntero al plan con un puntero a unos datos. Esto emite un comando a correr el plan remotamente, para que la localización predefinida de la salida del plan ahora contenga el resultado (recuerda que predefinimos la localización del resultado antes de la computación). Esto tambien necesita solo una ronda de comunicación.

El resultado es simplemente un puntero, igual al que llamas cuando usas una función torch enganchada.


In [ ]:
pointer_to_result = pointer_plan(pointer_to_data)
print(pointer_to_result)

Y solo pedimos el valor de vuelta


In [ ]:
pointer_to_result.get()

Hacia un ejemplo concreto

¿Queremos aplicar los Planes para el Aprendizaje Federado y Profundo, verdad? Veamos un ejemplo ligeramente más complicado, usando redes neuronales como quisieras hacerlo. Nota que ahora transformamos una clase en un Plan. Para hacerlo, heredamos nuestra clase de sy.Plan (en lugar de heredarla de nn.Module).


In [ ]:
class Net(sy.Plan):
    def __init__(self):
        super(Net, self).__init__()
        self.fc1 = nn.Linear(2, 3)
        self.fc2 = nn.Linear(3, 2)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = self.fc2(x)
        return F.log_softmax(x, dim=0)

In [ ]:
net = Net()

In [ ]:
net

Construyamos el plan usando datos simulados.


In [ ]:
net.build(torch.tensor([1., 2.]))

Ahora enviamos el Plan a un trabajador remoto


In [ ]:
pointer_to_net = net.send(device_1)
pointer_to_net

Recuperemos unos datos remotos


In [ ]:
pointer_to_data = device_1.search('input_data')[0]

Después, la sintaxis es igual que la ejecución secuencial remota normal, igual que la ejecución local. Pero comparada a la ejecución remota, hay solo una ronda de comunicación por cada ejecución.


In [ ]:
pointer_to_result = pointer_to_net(pointer_to_data)
pointer_to_result

Y obtenemos los resultados como de costumbre


In [ ]:
pointer_to_result.get()

Et voilà! Hemos visto como reducir dramáticamente la comunicación entre el trabajador local (o servidor) y los dispositivos remotos.

Cambiar de trabajadores

Una característica importante que queremos tener es usar el mismo Plan para varios trabajadores, que cambiemos dependiendo del grupo remoto de datos que estemos considerando.

En particular, no queremos reconstruir el Plan cada vez que cambiemos de trabajador. Veamos como hacer esto, usando el ejemplo anterior con nuestra pequeña red.


In [ ]:
class Net(sy.Plan):
    def __init__(self):
        super(Net, self).__init__()
        self.fc1 = nn.Linear(2, 3)
        self.fc2 = nn.Linear(3, 2)

    def forward(self, x):
        x = F.relu(self.fc1(x))
        x = self.fc2(x)
        return F.log_softmax(x, dim=0)

In [ ]:
net = Net()

# Construir el Plan
net.build(torch.tensor([1., 2.]))

Aqui están los pasos principales que acabamos de ejecutar


In [ ]:
pointer_to_net_1 = net.send(device_1)
pointer_to_data = device_1.search('input_data')[0]
pointer_to_result = pointer_to_net_1(pointer_to_data)
pointer_to_result.get()

Y de hecho, puedes construir otros PointerPlans del mismo Plan, dado que la sintaxis es la misma para correr remotamente un plan en otro dispositivo.


In [ ]:
pointer_to_net_2 = net.send(device_2)
pointer_to_data = device_2.search('input_data')[0]
pointer_to_result = pointer_to_net_2(pointer_to_data)
pointer_to_result.get()

Nota: Actualmente, con clases Plan, solo puedes usar un método y lo tienes que llamar "forward".

Construir Planes que son funciones automáticamente

Para funciones (@ sy.func2plan) que podemos construir automáticamente sin necesidad de explícitamente llamar build, en el momento de la creación el Plan ya está construido.

Para conseguir esta funcionalidad lo único que necesitas cambiar cuando creas el Plan es configurar un argumento para el decorador llamado args_shape el cual debe ser una lista conteniendo las formas de cada argumento.


In [ ]:
@sy.func2plan(args_shape=[(-1, 1)])
def plan_double_abs(x):
    x = x + x
    x = torch.abs(x)
    return x

plan_double_abs.is_built

El parámetro args_shape se usa interamente para crear tensores simulados con la forma dada que son usados para construir el Plan.


In [ ]:
@sy.func2plan(args_shape=[(1, 2), (-1, 2)])
def plan_sum_abs(x, y):
    s = x + y
    return torch.abs(s)

plan_sum_abs.is_built

¡También puedes darle elementos de estado a las funciones!


In [ ]:
@sy.func2plan(args_shape=[(1,)], state=(torch.tensor([1]), ))
def plan_abs(x, state):
    bias, = state.read()
    x = x.abs()
    return x + bias

In [ ]:
pointer_plan = plan_abs.send(device_1)
x_ptr = torch.tensor([-1, 0]).send(device_1)
p = pointer_plan(x_ptr)
p.get()

Para aprender más al respecto, puedes descubrir como usamos los Planes con Protocolos en el Tutorial Parte 08 Bis.

Dale una Estrella a PySyft en Github

¡La forma más fácil de ayudar a nuestra comunidad es guardando con una estrella los Repos! Esto ayuda a crear consciencia de las geniales herramientas que estamos construyendo.

¡Únete a nuestro Slack!

¡La mejor manera de estar al día con los últimos avances es unirte a nuestra comunidad! Puedes hacerlo llenando la forma en http://slack.openmined.org

¡Únete a un Proyecto de Programación!

¡La mejor manera de contribuir a nuestra comunidad es haciéndote un contribuidor de código! Puedes ir a PySyft Github Issues en cualquier momento y filtrar por "Projects". Esto te mostrará todos los Tickets de alto nivel, dando un resumen de los proyectos a los que puedes unirte. Si no quieres unirte a un proyecto, pero te gustaría programar un poco, puedes buscar mini-proyectos únicos buscando en Github Issues con "good first issue".

Donaciones

Si no tienes tiempo para contribuir a nuestra base de código, pero quieres brindarnos tu apoyo, puedes respaldarnos en nuestro Open Collective. Todas las donaciones van hacia nuestro alojamiento web y otros gastos de la comunidad como hackatones y reuniones.

OpenMined's Open Collective Page